📚 Introduzione: Il Cuore della Programmazione Strutturata
Immaginiamo di dover scrivere un programma che gestisce un sistema di prenotazione alberghiera.
Il programma deve calcolare il prezzo totale del soggiorno considerando il numero di notti, il tipo
di camera, gli eventuali sconti, le tasse, e molto altro. Se dovessimo scrivere tutto il codice in
un'unica sequenza lineare all'interno della funzione main(), ci troveremmo rapidamente
con centinaia di righe di codice impossibili da leggere, manutenere e debuggare. Ogni volta che
volessimo modificare il calcolo delle tasse, dovremmo cercare tra tutto il codice per trovare la
sezione giusta, rischiando di modificare inavvertitamente altre parti del programma.
Ma c'è un problema ancora più fondamentale: in molte situazioni, dobbiamo eseguire la stessa operazione più volte in punti diversi del programma. Pensate al calcolo del prezzo: potrebbe essere necessario sia quando l'utente richiede un preventivo, sia quando conferma la prenotazione, sia quando stampa la fattura. Senza funzioni, dovremmo copiare e incollare lo stesso codice tre volte. Cosa succede quando scopriamo un bug nel calcolo? Dovremmo ricordarci di correggerlo in tutti e tre i posti, con il rischio concreto di dimenticarne qualcuno e introdurre inconsistenze nel comportamento del programma.
Le funzioni risolvono brillantemente questi problemi. Una funzione è un blocco di
codice autonomo e riutilizzabile che esegue un compito specifico e ben definito. È come creare uno
"strumento specializzato" che può essere usato ogni volta che serve, senza doverlo ricostruire da zero.
Nel nostro esempio dell'hotel, potremmo creare una funzione calcolaPrezzo() che riceve
i parametri necessari (numero notti, tipo camera, ecc.) e restituisce il prezzo finale. Questa funzione
può essere chiamata da qualsiasi punto del programma, garantendo che il calcolo sia sempre coerente e
che eventuali correzioni debbano essere fatte in un unico posto.
Le funzioni non sono solo uno strumento per evitare la duplicazione del codice. Rappresentano il
fondamento stesso della programmazione strutturata, un paradigma che ha rivoluzionato
lo sviluppo software negli anni '60 e '70. Prima delle funzioni (o "procedure" e "subroutine" come
venivano chiamate), i programmi erano scritti usando l'istruzione GOTO, creando quello
che Edsger Dijkstra chiamò famosamente "spaghetti code" – codice così intricato e disorganizzato da
essere praticamente impossibile da comprendere e manutenere.
La programmazione con funzioni introduce il concetto di decomposizione funzionale: un problema complesso viene scomposto in sottoproblemi più piccoli e gestibili, ognuno affrontato da una funzione dedicata. Ogni funzione ha una responsabilità singola e ben definita (il principio della "Single Responsibility"), comunica con le altre attraverso parametri e valori di ritorno chiaramente specificati, e nasconde i dettagli implementativi dietro un'interfaccia pubblica pulita. Questo è ciò che chiamiamo astrazione e incapsulamento, concetti che ritroveremo anche nella programmazione orientata agli oggetti.
In C, le funzioni hanno caratteristiche peculiari che le rendono sia potenti che pericolose. A differenza di linguaggi più moderni, il C segue rigorosamente il paradigma del passaggio per valore: quando passi un argomento a una funzione, viene creata una copia di quel valore, e la funzione lavora sulla copia, non sull'originale. Questo significa che, per default, una funzione non può modificare le variabili del chiamante. Tuttavia, attraverso i puntatori (trattati in dettaglio nella lezione dedicata), possiamo simulare il passaggio per riferimento e permettere alle funzioni di modificare i dati del chiamante.
La gestione della memoria nelle funzioni C avviene principalmente attraverso lo stack, una regione di memoria organizzata in modo LIFO (Last In, First Out). Quando una funzione viene chiamata, viene creato un nuovo "stack frame" contenente i parametri della funzione, le variabili locali, e l'indirizzo di ritorno. Questo stack frame viene distrutto quando la funzione termina, rendendo inaccessibili tutte le variabili locali – un comportamento che può causare bug subdoli se non compreso correttamente. Approfondiremo questi meccanismi nelle sezioni dedicate.
In questa lezione esploreremo ogni aspetto delle funzioni in C con un livello di dettaglio senza precedenti. Partiremo dai concetti fondamentali della dichiarazione e definizione, comprendendo la differenza cruciale tra prototipo e implementazione. Analizzeremo in profondità il meccanismo di chiamata di funzione, il passaggio dei parametri, la gestione dello stack, e il valore di ritorno. Studieremo lo scope (visibilità) e il lifetime (durata) delle variabili, concetti fondamentali per capire come le funzioni interagiscono con i dati. Esamineremo tecniche avanzate come la ricorsione, i puntatori a funzione, le funzioni variadiche, e i callback. Vedremo le best practices per scrivere funzioni robuste, leggibili e manutenibili, e analizzeremo gli errori più comuni e come evitarli.
Al termine di questa lezione, non solo saprai scrivere funzioni in C, ma comprenderai intimamente come funzionano a livello di assembly e processore, perché sono progettate così, quali sono i trade-off delle diverse scelte progettuali, e come sfruttarle al meglio per costruire software professionale, efficiente e manutenibile. Preparati per un viaggio nel cuore della programmazione procedurale.
1. Concetti Fondamentali: Anatomia di una Funzione
1.1 Definizione Formale e Terminologia Essenziale
Una funzione, nel contesto della programmazione C, è un sottoprogramma indipendente che incapsula una sequenza di istruzioni destinate a svolgere un compito specifico. Ogni funzione è caratterizzata da quattro componenti fondamentali che ne definiscono completamente il comportamento e l'interfaccia:
-
1. Nome della Funzione (Identificatore): È l'identificatore univoco che
permette di invocare la funzione. Deve seguire le stesse regole dei nomi di variabili in C:
può contenere lettere, cifre e underscore, ma deve iniziare con una lettera o underscore.
Per convenzione, i nomi di funzione in C sono tipicamente scritti in
camelCaseosnake_case. Un buon nome di funzione è descrittivo e indica chiaramente cosa fa la funzione (ad esempio:calcolaMedia,stampa_matrice,ordina_array). -
2. Tipo di Ritorno (Return Type): Specifica il tipo di dato che la funzione
restituisce al chiamante dopo l'esecuzione. Può essere qualsiasi tipo valido in C: tipi
primitivi (
int,float,char, ecc.), puntatori, strutture, ovoidse la funzione non restituisce alcun valore. Il tipo di ritorno è parte integrante del contratto tra la funzione e il suo chiamante: chi chiama la funzione sa esattamente che tipo di dato aspettarsi in risposta. -
3. Lista dei Parametri (Parameter List): È la lista di variabili che la
funzione accetta come input. Ogni parametro è specificato con il suo tipo e nome. I parametri
sono separati da virgole e racchiusi tra parentesi tonde. Se una funzione non accetta parametri,
si scrive
(void)in C (anche se le parentesi vuote()sono accettate, hanno un significato leggermente diverso). I parametri definiscono l'interfaccia di input della funzione e determinano quali informazioni devono essere fornite per eseguirla correttamente. -
4. Corpo della Funzione (Function Body): È il blocco di codice racchiuso tra
parentesi graffe
{ }che contiene le istruzioni da eseguire quando la funzione viene chiamata. Il corpo della funzione ha accesso ai parametri passati, può dichiarare variabili locali, può chiamare altre funzioni, e deve terminare con un'istruzionereturnse il tipo di ritorno non èvoid. Il corpo implementa la logica effettiva della funzione, trasformando gli input in output secondo l'algoritmo desiderato.
Comprendere la distinzione tra dichiarazione (o prototipo) e definizione di una funzione è fondamentale. La dichiarazione specifica solo la firma della funzione (nome, tipo di ritorno, e tipi dei parametri) senza fornire l'implementazione. Serve a informare il compilatore dell'esistenza della funzione prima che questa venga effettivamente utilizzata nel codice. La definizione, invece, include sia la firma che il corpo della funzione, fornendo l'implementazione completa.
// DICHIARAZIONE (Prototipo) - Tipicamente nel file header (.h) // Comunica al compilatore che esiste una funzione con questa firma int somma(int a, int b); // DEFINIZIONE - Tipicamente nel file sorgente (.c) // Fornisce l'implementazione effettiva della funzione int somma(int a, int b) { return a + b; } // Anatomia completa di una definizione di funzione: // // [tipo_ritorno] [nome_funzione]([tipo_param1] [nome_param1], [tipo_param2] [nome_param2], ...) // { // [corpo della funzione] // [return valore;] // se tipo_ritorno != void // } // Esempio completo con tutte le componenti annotate: float // ← Tipo di ritorno: questa funzione restituisce un float calcolaMedia // ← Nome della funzione: identificatore descrittivo ( // ← Inizio lista parametri int *voti, // ← Primo parametro: puntatore a int (array di voti) int numVoti // ← Secondo parametro: numero di elementi nell'array ) // ← Fine lista parametri { // ← Inizio corpo della funzione if (numVoti <= 0) return 0.0f; // Validazione input int somma = 0; for (int i = 0; i < numVoti; i++) { somma += voti[i]; } return (float)somma / numVoti; // ← Istruzione return: restituisce il risultato } // ← Fine corpo della funzione
1.2 Il Ruolo Cruciale dei Prototipi: Forward Declaration
Nel linguaggio C, il compilatore legge il codice sorgente dall'alto verso il basso, una riga alla volta. Questo significa che quando il compilatore incontra una chiamata a funzione, deve già conoscere la firma di quella funzione per verificare che la chiamata sia corretta (numero e tipo di argomenti corrispondenti). Se la funzione è definita dopo il punto in cui viene chiamata, il compilatore non la conosce ancora e genera un errore.
I prototipi di funzione risolvono questo problema attraverso quello che viene chiamato
forward declaration (dichiarazione anticipata). Un prototipo è essenzialmente la firma
della funzione seguita da un punto e virgola, senza il corpo. Inserendo i prototipi all'inizio del file
sorgente (tipicamente nei file header .h), informiamo il compilatore dell'esistenza delle
funzioni prima che vengano effettivamente utilizzate, permettendo così di chiamarle in qualsiasi ordine.
Consideriamo il seguente scenario problematico senza prototipi:
// CODICE PROBLEMATICO - GENERA ERRORE DI COMPILAZIONE int main(void) { int risultato = somma(5, 3); // ERRORE! somma() non è ancora definita printf("Risultato: %d\n", risultato); return 0; } int somma(int a, int b) { return a + b; } // Il compilatore, arrivato alla linea con somma(5, 3), non sa ancora che // la funzione somma() esiste, quali parametri accetta, e cosa restituisce.
La soluzione corretta usa un prototipo:
// CODICE CORRETTO - USA IL PROTOTIPO // Prototipo: dichiara l'esistenza della funzione int somma(int a, int b); int main(void) { int risultato = somma(5, 3); // OK! Il compilatore conosce somma() printf("Risultato: %d\n", risultato); return 0; } // Definizione: fornisce l'implementazione int somma(int a, int b) { return a + b; }
Un aspetto importante dei prototipi è che i nomi dei parametri sono opzionali – al compilatore interessa solo il tipo. Tuttavia, è considerata una best practice includerli per documentare il significato di ogni parametro:
// Prototipo minimale (valido ma poco documentativo) float calcolaSconto(float, float); // Prototipo documentativo (preferibile) float calcolaSconto(float prezzoOriginale, float percentualeSconto); // Quando qualcuno legge il prototipo, capisce immediatamente cosa fa la funzione // senza dover cercare la definizione o consultare altra documentazione
2. Meccanismi di Passaggio dei Parametri: Pass by Value in Profondità
2.1 Il Paradigma del Passaggio per Valore: Copia Profonda dei Dati
Il C è un linguaggio che implementa rigorosamente il passaggio per valore (pass by value). Questo significa che quando passi una variabile come argomento a una funzione, il C non passa la variabile originale, ma crea una copia del suo valore. La funzione riceve e lavora sulla copia, non sull'originale. Di conseguenza, qualsiasi modifica apportata al parametro all'interno della funzione non ha alcun effetto sulla variabile originale nel contesto del chiamante.
Questo comportamento è radicalmente diverso da linguaggi come Java (per gli oggetti) o Python, dove i riferimenti agli oggetti vengono passati. In C, anche per tipi complessi come le strutture, viene sempre creata una copia completa. Questo ha implicazioni profonde sia per la semantica del linguaggio che per le prestazioni.
// Dimostrazione del passaggio per valore #include <stdio.h> // Funzione che tenta di modificare il parametro void tentativoModifica(int x) { printf(" Dentro la funzione, prima: x = %d\n", x); x = 100; // Modifica la COPIA locale printf(" Dentro la funzione, dopo: x = %d\n", x); } int main(void) { int numero = 42; printf("Prima della chiamata: numero = %d\n", numero); tentativoModifica(numero); printf("Dopo la chiamata: numero = %d\n", numero); // numero è ancora 42! return 0; } /* OUTPUT: Prima della chiamata: numero = 42 Dentro la funzione, prima: x = 42 Dentro la funzione, dopo: x = 100 Dopo la chiamata: numero = 42 Spiegazione: - Quando chiamiamo tentativoModifica(numero), viene creata una copia di 'numero' - Questa copia viene assegnata al parametro 'x' della funzione - La modifica 'x = 100' agisce solo sulla copia locale - Quando la funzione termina, 'x' viene distrutto - La variabile originale 'numero' rimane inalterata */
2.2 Analisi Approfondita: Cosa Succede nello Stack
Per comprendere veramente il passaggio per valore, dobbiamo esaminare cosa accade a livello di memoria durante una chiamata di funzione. Quando una funzione viene invocata, il sistema crea un nuovo stack frame (o activation record) sullo stack. Questo frame contiene:
- I valori dei parametri passati alla funzione (copie degli argomenti)
- Tutte le variabili locali dichiarate nella funzione
- L'indirizzo di ritorno (dove riprendere l'esecuzione dopo il return)
- Informazioni di controllo per gestire il contesto di esecuzione
Visualizzazione dello Stack durante una Chiamata di Funzione
Consideriamo questo codice: int main() { int a = 5; somma(a, 3); }
parametro x: 5 (COPIA di a)
parametro y: 3
variabile locale risultato: ???
indirizzo di ritorno: 0x... ← Punto di ritorno in main
variabile a: 5 ← ORIGINALE inalterato
indirizzo di ritorno: 0x... ← Punto di ritorno al sistema operativo
Quando somma() modifica x, sta modificando solo la sua copia locale nello
stack frame di somma(). La variabile a nel frame di main()
rimane completamente isolata e inalterata. Quando somma() termina con return,
il suo stack frame viene distrutto, liberando tutta la memoria locale, e l'esecuzione
riprende in main() con tutte le sue variabili intatte.
2.3 Simulare il Passaggio per Riferimento con i Puntatori
Se vogliamo che una funzione modifichi effettivamente una variabile del chiamante, dobbiamo passare un puntatore a quella variabile. Tecnicamente questo è ancora passaggio per valore (stiamo passando il valore dell'indirizzo di memoria), ma l'effetto pratico è quello del passaggio per riferimento: la funzione può accedere e modificare direttamente la variabile originale.
I puntatori sono un argomento complesso e fondamentale in C, trattato in dettaglio nella lezione dedicata ai puntatori. Qui forniamo solo un'introduzione essenziale per comprendere il passaggio di parametri. Per una comprensione approfondita dell'aritmetica dei puntatori, della dereferenziazione, e delle best practices, si rimanda alla lezione specifica.
Visualizza esempio: Passaggio per "riferimento" tramite puntatori
#include <stdio.h> // Funzione che modifica la variabile originale tramite puntatore void modificaEffettiva(int *ptr) { // ← Parametro è un PUNTATORE a int printf(" Dentro la funzione, indirizzo: %p\n", (void*)ptr); printf(" Dentro la funzione, prima: *ptr = %d\n", *ptr); *ptr = 100; // ← Dereferenzia il puntatore e modifica il valore puntato printf(" Dentro la funzione, dopo: *ptr = %d\n", *ptr); } int main(void) { int numero = 42; printf("Indirizzo di numero: %p\n", (void*)&numero); printf("Prima della chiamata: numero = %d\n", numero); modificaEffettiva(&numero); // ← Passiamo l'INDIRIZZO di numero printf("Dopo la chiamata: numero = %d\n", numero); // numero È CAMBIATO! return 0; } /* OUTPUT (indirizzi variano): Indirizzo di numero: 0x7ffe8b4c2d1c Prima della chiamata: numero = 42 Dentro la funzione, indirizzo: 0x7ffe8b4c2d1c ← Stesso indirizzo! Dentro la funzione, prima: *ptr = 42 Dentro la funzione, dopo: *ptr = 100 Dopo la chiamata: numero = 100 ← MODIFICATO! Spiegazione dettagliata: 1. &numero è l'operatore 'address-of': restituisce l'indirizzo di memoria di numero 2. Passiamo questo indirizzo alla funzione: modificaEffettiva(&numero) 3. Il parametro 'ptr' riceve una COPIA dell'indirizzo (pass by value dell'indirizzo) 4. Attraverso l'operatore * (dereferenziazione), accediamo al valore all'indirizzo puntato 5. *ptr = 100 modifica il contenuto della memoria puntata, cioè la variabile originale! */
2.4 Passaggio di Array: Un Caso Speciale
Gli array in C hanno un comportamento particolare quando passati a funzioni. Il nome di un array, quando usato come argomento in una chiamata di funzione, "decade" automaticamente in un puntatore al suo primo elemento. Questo significa che, nonostante il C usi il passaggio per valore, quando passi un array stai effettivamente passando un puntatore alla sua prima posizione. Di conseguenza, le modifiche agli elementi dell'array fatte all'interno della funzione si riflettono sull'array originale.
Quando un array viene passato a una funzione:
- L'array "decade" in un puntatore al primo elemento
- La funzione non conosce la dimensione dell'array (informazione persa)
- È necessario passare la dimensione come parametro separato
- Le modifiche agli elementi si riflettono sull'array originale
Per approfondimenti sulla relazione tra array e puntatori, consultare la lezione sugli array dove questo argomento è trattato in dettaglio.
Visualizza esempio: Passaggio di array a funzioni
#include <stdio.h> // Le seguenti dichiarazioni sono EQUIVALENTI in C: void elabora_array_v1(int arr[], int size); // Sintassi con [] void elabora_array_v2(int *arr, int size); // Sintassi con puntatore // Entrambe significano: "arr è un puntatore a int" // Implementazione void elabora_array_v1(int arr[], int size) { printf("Dimensione del 'parametro arr': %zu byte\n", sizeof(arr)); // ⚠️ sizeof(arr) restituisce la dimensione di un PUNTATORE (8 byte su 64-bit), // NON la dimensione dell'array originale! printf("Modifico gli elementi...\n"); for (int i = 0; i < size; i++) { arr[i] *= 2; // Modifica l'array ORIGINALE } } int main(void) { int numeri[] = {1, 2, 3, 4, 5}; int size = sizeof(numeri) / sizeof(numeri[0]); printf("Dimensione dell'array originale: %d elementi (%zu byte)\n", size, sizeof(numeri)); printf("Array prima: "); for (int i = 0; i < size; i++) { printf("%d ", numeri[i]); } printf("\n"); elabora_array_v1(numeri, size); printf("Array dopo: "); for (int i = 0; i < size; i++) { printf("%d ", numeri[i]); } printf("\n"); return 0; } /* OUTPUT: Dimensione dell'array originale: 5 elementi (20 byte) Array prima: 1 2 3 4 5 Dimensione del 'parametro arr': 8 byte Modifico gli elementi... Array dopo: 2 4 6 8 10 Osservazioni chiave: 1. sizeof(numeri) in main() = 20 byte (5 int × 4 byte) 2. sizeof(arr) nella funzione = 8 byte (dimensione di un puntatore su sistema 64-bit) 3. Le modifiche agli elementi si riflettono sull'array originale 4. È ESSENZIALE passare la dimensione come parametro separato */
3. Valori di Ritorno e Istruzione return
3.1 Il Meccanismo del Valore di Ritorno
L'istruzione return serve a due scopi fondamentali: (1) terminare immediatamente l'esecuzione
della funzione, e (2) restituire un valore al chiamante (se la funzione non è di tipo void).
Quando viene eseguita un'istruzione return, il flusso di controllo ritorna immediatamente
al punto di chiamata, e qualsiasi codice successivo al return nella funzione viene ignorato.
// Sintassi e semantica di return int calcola_massimo(int a, int b) { if (a > b) { return a; // Esce immediatamente, restituendo a } return b; // Se arriviamo qui, b >= a } // Una funzione può avere più istruzioni return int classifica_voto(int voto) { if (voto < 0 || voto > 100) return -1; // Errore: voto non valido if (voto >= 90) return 5; // Eccellente if (voto >= 80) return 4; // Ottimo if (voto >= 70) return 3; // Buono if (voto >= 60) return 2; // Sufficiente return 1; // Insufficiente } // Funzione void: return senza valore void stampa_messaggio(const char *msg) { if (msg == NULL) { return; // Esce precocemente se msg è NULL } printf("%s\n", msg); // return implicito alla fine di funzioni void }
Se una funzione dichiara un tipo di ritorno diverso da void ma non tutte le sue
code paths terminano con un return, il comportamento è undefined.
Il valore restituito sarà garbage (imprevedibile). Questo è uno degli errori più insidiosi in C
perché il compilatore potrebbe non avvertirti sempre:
// CODICE PERICOLOSO - COMPORTAMENTO NON DEFINITO! int calcola_valore(int x) { if (x > 0) { return x * 2; } // ⚠️ MANCA RETURN per il caso x <= 0! // Se x <= 0, la funzione "cade fuori" senza restituire nulla // Il valore restituito sarà IMPREVEDIBILE (undefined behavior) } // VERSIONE CORRETTA int calcola_valore_corretto(int x) { if (x > 0) { return x * 2; } return 0; // Gestisce esplicitamente il caso x <= 0 }
Best Practice: Compila sempre con i warning abilitati (-Wall -Wextra
in GCC/Clang) per rilevare return mancanti.
4. Scope e Lifetime delle Variabili
4.1 Lo Scope (Visibilità): Dove una Variabile è Accessibile
Lo scope di una variabile definisce la regione del codice in cui quella variabile è visibile e può essere utilizzata. In C esistono principalmente quattro tipi di scope:
-
Block Scope (Scope di Blocco): Variabili dichiarate all'interno di un blocco
{ }sono visibili solo all'interno di quel blocco. Include variabili locali nelle funzioni, parametri di funzione, e variabili dichiarate in loop o if. -
Function Scope: Le etichette (per
goto) hanno function scope – sono visibili in tutta la funzione. Questo è l'unico caso di function scope in C. - File Scope: Variabili dichiarate fuori da qualsiasi funzione (variabili globali) hanno file scope – sono visibili dall'punto di dichiarazione fino alla fine del file sorgente.
- Function Prototype Scope: I nomi dei parametri in un prototipo di funzione hanno uno scope limitato al prototipo stesso e sono irrilevanti.
Visualizza esempio completo: Scope delle variabili
#include <stdio.h> // Variabile GLOBALE - File scope // Visibile in tutto il file, dopo la sua dichiarazione int contatore_globale = 0; void funzione1(void) { // 'x' ha block scope - visibile solo in questa funzione int x = 10; contatore_globale++; // Accesso alla globale: OK printf("funzione1 - x: %d, globale: %d\n", x, contatore_globale); { // Blocco innestato - nuovo scope int y = 20; // 'y' visibile solo in questo blocco int x = 100; // Questa 'x' NASCONDE la 'x' esterna (shadowing) printf(" Blocco interno - x: %d, y: %d\n", x, y); } // Qui 'y' NON esiste più (out of scope) // printf("%d", y); ← ERRORE DI COMPILAZIONE! // La 'x' originale è di nuovo visibile printf("funzione1 - x dopo blocco: %d\n", x); // Stampa 10, non 100 } void funzione2(void) { // Questa funzione NON può accedere alla 'x' di funzione1 // Ma può accedere alla variabile globale contatore_globale++; printf("funzione2 - globale: %d\n", contatore_globale); // printf("%d", x); ← ERRORE! 'x' non è visibile qui } int main(void) { printf("=== Dimostrazione Scope ===\n"); funzione1(); funzione2(); funzione1(); printf("main - globale finale: %d\n", contatore_globale); return 0; } /* Concetti chiave: 1. Variabili locali (block scope) sono ISOLATE tra funzioni diverse 2. Variabili globali sono CONDIVISE tra tutte le funzioni 3. Blocchi innestati possono "nascondere" variabili esterne (shadowing) 4. Quando un blocco termina, tutte le sue variabili locali diventano inaccessibili */
4.2 Il Lifetime (Durata): Quando una Variabile Esiste in Memoria
Il lifetime di una variabile definisce per quanto tempo quella variabile esiste fisicamente in memoria. È distinto dallo scope: una variabile può esistere in memoria ma non essere accessibile (fuori scope). In C, il lifetime dipende dalla storage class della variabile:
- Automatic Storage Duration: Variabili locali (dichiarate dentro funzioni senza qualificatori speciali) hanno durata automatica. Vengono create quando viene raggiunto il punto di dichiarazione e distrutte quando il blocco termina. Allocate sullo stack.
-
Static Storage Duration: Variabili globali e variabili dichiarate con
staticesistono per tutta la durata del programma. Allocate nel segmento dati (data segment), non sullo stack. Inizializzate automaticamente a zero se non specificato. -
Dynamic Storage Duration: Memoria allocata con
malloc()esiste fino a quando non viene liberata confree(). Allocata nell'heap. (Argomento approfondito in lezioni successive).
Visualizza esempio: Automatic vs Static Storage
#include <stdio.h> void conta_chiamate_automatica(void) { int contatore = 0; // Variabile AUTOMATICA (locale) contatore++; printf("Conta automatica: %d\n", contatore); // Al return, 'contatore' viene DISTRUTTO } void conta_chiamate_statica(void) { static int contatore = 0; // Variabile STATICA // 'static' qui significa: "esiste per tutta la durata del programma" // L'inizializzazione '= 0' avviene UNA SOLA VOLTA, non ad ogni chiamata contatore++; printf("Conta statica: %d\n", contatore); // Al return, 'contatore' NON viene distrutto, mantiene il suo valore } int main(void) { printf("=== Chiamate alla funzione con variabile automatica ===\n"); conta_chiamate_automatica(); // Stampa: 1 conta_chiamate_automatica(); // Stampa: 1 ← Sempre 1! conta_chiamate_automatica(); // Stampa: 1 printf("\n=== Chiamate alla funzione con variabile statica ===\n"); conta_chiamate_statica(); // Stampa: 1 conta_chiamate_statica(); // Stampa: 2 ← Incrementa! conta_chiamate_statica(); // Stampa: 3 return 0; } /* Spiegazione: Variabile AUTOMATICA: - Viene creata ogni volta che la funzione viene chiamata - Viene inizializzata a 0 ogni volta - Viene distrutta quando la funzione termina - Non conserva il valore tra chiamate successive Variabile STATICA: - Viene creata UNA SOLA VOLTA all'avvio del programma - L'inizializzazione '= 0' avviene solo la prima volta - NON viene distrutta quando la funzione termina - Conserva il valore tra chiamate successive - Ma ha ancora block scope: non accessibile fuori dalla funzione */
5. Ricorsione: Funzioni che Chiamano Se Stesse
5.1 Concetto di Ricorsione e Caso Base
La ricorsione è una tecnica di programmazione in cui una funzione chiama se stessa, direttamente o indirettamente. È un concetto potente che permette di risolvere problemi complessi scomponendoli in istanze più piccole dello stesso problema. Ogni chiamata ricorsiva crea un nuovo stack frame, con le proprie copie locali delle variabili e dei parametri.
Una funzione ricorsiva deve sempre avere un caso base (o condizione di terminazione): una situazione in cui la funzione restituisce un valore senza effettuare ulteriori chiamate ricorsive. Senza un caso base corretto, la funzione continuerebbe a chiamarsi all'infinito, causando uno stack overflow.
Visualizza esempio classico: Fattoriale ricorsivo
#include <stdio.h> /** * Calcola il fattoriale di n ricorsivamente * Definizione matematica: * n! = n × (n-1)! per n > 0 * 0! = 1 (caso base) */ unsigned long long fattoriale(int n) { // CASO BASE: fondamentale per fermare la ricorsione if (n == 0 || n == 1) { return 1; } // CASO RICORSIVO: riduce il problema a un'istanza più piccola return n * fattoriale(n - 1); } // Versione con tracciamento per capire il flusso di esecuzione unsigned long long fattoriale_tracciato(int n, int livello) { // Stampa indentazione proporzionale al livello di ricorsione for (int i = 0; i < livello; i++) printf(" "); printf("→ fattoriale(%d) chiamato\n", n); if (n == 0 || n == 1) { for (int i = 0; i < livello; i++) printf(" "); printf("← fattoriale(%d) restituisce 1 (caso base)\n", n); return 1; } unsigned long long risultato = n * fattoriale_tracciato(n - 1, livello + 1); for (int i = 0; i < livello; i++) printf(" "); printf("← fattoriale(%d) restituisce %llu\n", n, risultato); return risultato; } int main(void) { int n = 5; printf("Calcolo di %d! = %llu\n\n", n, fattoriale(n)); printf("\n=== Tracciamento delle chiamate ricorsive ===\n"); fattoriale_tracciato(5, 0); return 0; } /* OUTPUT del tracciamento: → fattoriale(5) chiamato → fattoriale(4) chiamato → fattoriale(3) chiamato → fattoriale(2) chiamato → fattoriale(1) chiamato ← fattoriale(1) restituisce 1 (caso base) ← fattoriale(2) restituisce 2 ← fattoriale(3) restituisce 6 ← fattoriale(4) restituisce 24 ← fattoriale(5) restituisce 120 Analisi del flusso: 1. fattoriale(5) chiama fattoriale(4) 2. fattoriale(4) chiama fattoriale(3) 3. ... fino a fattoriale(1) che raggiunge il CASO BASE 4. fattoriale(1) restituisce 1 5. Questo valore risale attraverso lo stack: - fattoriale(2) calcola 2 × 1 = 2 - fattoriale(3) calcola 3 × 2 = 6 - fattoriale(4) calcola 4 × 6 = 24 - fattoriale(5) calcola 5 × 24 = 120 */
Ogni chiamata ricorsiva consuma spazio sullo stack per creare un nuovo stack frame. Se la ricorsione è troppo profonda (troppi livelli), lo stack può esaurirsi, causando uno stack overflow – un crash del programma:
// CODICE PERICOLOSO - CAUSA STACK OVERFLOW! unsigned long long fattoriale_senza_limite(int n) { if (n == 0) return 1; return n * fattoriale_senza_limite(n - 1); } // Se chiami fattoriale_senza_limite(-1), il caso base non viene mai raggiunto: // fattoriale(-1) chiama fattoriale(-2) chiama fattoriale(-3) ... all'infinito! // Dopo centinaia/migliaia di chiamate, lo stack esplode. // VERSIONE SICURA - CON VALIDAZIONE unsigned long long fattoriale_sicuro(int n) { if (n < 0) { fprintf(stderr, "Errore: fattoriale di numero negativo!\n"); return 0; // O gestisci l'errore in altro modo } if (n == 0 || n == 1) return 1; if (n > 20) { fprintf(stderr, "Errore: fattoriale troppo grande (overflow)!\n"); return 0; } return n * fattoriale_sicuro(n - 1); }
Best Practices per la Ricorsione:
- Assicurati SEMPRE che esista un caso base raggiungibile
- Valida gli input per evitare ricorsioni infinite
- Considera limiti sulla profondità di ricorsione per input grandi
- Per problemi molto grandi, considera soluzioni iterative (con loop)
Visualizza esempio avanzato: Sequenza di Fibonacci ricorsiva vs iterativa
#include <stdio.h> #include <time.h> /** * Fibonacci ricorsivo INGENUO (molto inefficiente!) * Complessità: O(2^n) - esponenziale! * * Definizione: fib(n) = fib(n-1) + fib(n-2) * fib(0) = 0, fib(1) = 1 */ unsigned long long fib_ricorsivo(int n) { if (n <= 1) return n; return fib_ricorsivo(n - 1) + fib_ricorsivo(n - 2); } /** * Fibonacci iterativo (molto più efficiente!) * Complessità: O(n) - lineare */ unsigned long long fib_iterativo(int n) { if (n <= 1) return n; unsigned long long prev = 0, curr = 1; for (int i = 2; i <= n; i++) { unsigned long long next = prev + curr; prev = curr; curr = next; } return curr; } int main(void) { int n = 40; printf("Calcolo di Fibonacci(%d)\n\n", n); // Test versione ricorsiva (LENTO per n > 35) printf("Versione ricorsiva...\n"); clock_t start = clock(); unsigned long long ris_ric = fib_ricorsivo(n); clock_t end = clock(); double tempo_ric = ((double)(end - start)) / CLOCKS_PER_SEC; printf("Risultato: %llu\n", ris_ric); printf("Tempo: %.6f secondi\n\n", tempo_ric); // Test versione iterativa (VELOCE) printf("Versione iterativa...\n"); start = clock(); unsigned long long ris_iter = fib_iterativo(n); end = clock(); double tempo_iter = ((double)(end - start)) / CLOCKS_PER_SEC; printf("Risultato: %llu\n", ris_iter); printf("Tempo: %.6f secondi\n\n", tempo_iter); printf("Speedup: %.0fx più veloce!\n", tempo_ric / tempo_iter); return 0; } /* OUTPUT tipico: Versione ricorsiva... Risultato: 102334155 Tempo: 1.523400 secondi Versione iterativa... Risultato: 102334155 Tempo: 0.000001 secondi Speedup: 1523400x più veloce! Perché la ricorsione è così lenta qui? - fib_ricorsivo(5) calcola fib(4) e fib(3) - Ma fib(4) ricalcola fib(3) e fib(2)! - fib(3) viene calcolato DUE VOLTE - Questa duplicazione cresce esponenzialmente - Per fib(40), ci sono MILIARDI di calcoli ridondanti! La versione iterativa calcola ogni fib(i) UNA SOLA VOLTA. Lezione: la ricorsione non è sempre la soluzione migliore! */